/** * Public Dynamic Catch-All Route * * Handles all public URLs for builder-enabled entities based on access.basePath configuration. * * Resolution strategy (longest-match-first): * - /blog/my-post → posts (basePath: '/blog') * - /about → pages (basePath: '/') * - /blog → posts archive (exact basePath match) * * Template System: * 1. Checks for theme template override first * 2. Falls back to default PageRenderer if no template */ import { Suspense } from 'react' import { notFound } from 'next/navigation' import { query } from '@nextsparkjs/core/lib/db' import { PageRenderer } from '@nextsparkjs/core/components/public/pageBuilder' import { matchPathToEntity, getEntityBasePath, } from '@nextsparkjs/core/lib/entities/schema-generator' import { getEntityRegistry, setEntityRegistry } from '@nextsparkjs/core/lib/entities/queries' import { TemplateService } from '@nextsparkjs/core/lib/services/template.service' import { resolvePublicEntityFromUrl } from '@nextsparkjs/core/lib/api/entity/public-resolver' import { PublicEntityGrid } from '@nextsparkjs/core/components/public/entities/PublicEntityGrid' import type { EntityConfig } from '@nextsparkjs/core/lib/entities/types' import type { Metadata } from 'next' // Pattern resolution imports import { PatternsResolverService } from '@nextsparkjs/core/lib/blocks/patterns-resolver.service' import { extractPatternIds, resolvePatternReferences } from '@nextsparkjs/core/lib/blocks/pattern-resolver' import type { BlockInstance } from '@nextsparkjs/core/types/blocks' import type { PatternReference } from '@nextsparkjs/core/types/pattern-reference' // Import registry directly - webpack resolves @nextsparkjs/registries alias at compile time import { ENTITY_REGISTRY, ENTITY_METADATA } from '@nextsparkjs/registries/entity-registry' // Initialize registry at module load time (before any component renders) // This ensures the registry is available even if this page loads before the layout setEntityRegistry(ENTITY_REGISTRY, ENTITY_METADATA) /** * Convert entity registry to format expected by matchPathToEntity */ function getEntityConfigs(): Record { const registry = getEntityRegistry() const configs: Record = {} for (const [key, entry] of Object.entries(registry)) { configs[key] = entry.config as EntityConfig } return configs } // Enable ISR with 1 hour revalidation // On Next.js 16 with cacheComponents, this is superseded by 'use cache' + cacheLife() export const revalidate = 3600 interface PageProps { params: Promise<{ slug: string[] }> searchParams: Promise<{ [key: string]: string | string[] | undefined }> } /** * Base fields that all builder-enabled entities have (from migrations) * These are the minimum fields needed for public page rendering */ const BASE_PUBLIC_FIELDS = ['id', 'slug', 'title', 'status', 'blocks', 'locale', 'createdAt', 'userId'] /** * Optional SEO fields that may exist on builder entities */ const SEO_FIELDS = ['seoTitle', 'seoDescription', 'ogImage'] /** * Common optional fields for content entities (posts, articles, etc.) * These are checked dynamically - only included if they exist in entity.fields */ const OPTIONAL_CONTENT_FIELDS = ['excerpt', 'featuredImage'] interface PublishedItem { id: string slug: string title: string status: string blocks: Array<{ id: string blockSlug: string props: Record }> excerpt?: string featuredImage?: string seoTitle?: string seoDescription?: string ogImage?: string locale?: string createdAt?: string userId?: string } /** * Quote a field name for PostgreSQL (handles camelCase) */ function quoteField(field: string): string { return /[A-Z]/.test(field) ? `"${field}"` : field } /** * Build SELECT clause dynamically based on entity configuration * Only includes fields that actually exist in the entity's schema */ function buildPublicSelectClause(entity: EntityConfig): string { const fields = new Set(BASE_PUBLIC_FIELDS) // Add SEO fields (these are standard for builder entities) SEO_FIELDS.forEach(f => fields.add(f)) // Check entity.fields for optional content fields const entityFieldNames = entity.fields?.map(f => f.name) || [] OPTIONAL_CONTENT_FIELDS.forEach(f => { if (entityFieldNames.includes(f)) { fields.add(f) } }) return Array.from(fields).map(quoteField).join(', ') } /** * Resolve pattern references in blocks array * * Fetches all referenced patterns and expands them inline. * This ensures public pages show the actual pattern content. * * @param blocks - Blocks array which may contain pattern references * @returns Resolved blocks array with patterns expanded */ async function getResolvedBlocks( blocks: (BlockInstance | PatternReference)[] ): Promise { // Extract pattern IDs from blocks array const patternIds = extractPatternIds(blocks) // If no patterns referenced, return blocks as-is if (patternIds.length === 0) { return blocks as BlockInstance[] } try { // Batch fetch all referenced patterns (only published ones) const patterns = await PatternsResolverService.getByIds(patternIds) // Build pattern cache (Map for O(1) lookup) const patternCache = new Map(patterns.map((p) => [p.id, p])) // Resolve pattern references and return flattened blocks return resolvePatternReferences(blocks, patternCache) } catch (error) { console.error('[getResolvedBlocks] Failed to resolve patterns:', error) // Fallback: Return blocks without pattern resolution // Pattern references will be skipped gracefully return blocks.filter((block) => !('type' in block && block.type === 'pattern')) as BlockInstance[] } } /** * Build the template path for an entity based on its basePath */ function buildTemplatePath(entity: EntityConfig): string { const basePath = getEntityBasePath(entity) || '/' if (basePath === '/') { return 'app/(public)/[entity]/page.tsx' } return `app/(public)${basePath}/[slug]/page.tsx` } /** * Fetch a published item from the database * Dynamically builds SELECT clause based on entity configuration * to avoid querying non-existent columns */ async function fetchPublishedItem( entity: EntityConfig, slug: string ): Promise { try { const tableName = entity.tableName || entity.slug const selectClause = buildPublicSelectClause(entity) const result = await query( `SELECT ${selectClause} FROM "${tableName}" WHERE slug = $1 AND status = 'published'`, [slug] ) return result.rows[0] || null } catch (error) { console.error(`[fetchPublishedItem] Error fetching ${entity.slug}:`, error) return null } } /** * Generate metadata for the page */ export async function generateMetadata({ params, }: PageProps): Promise { const slugParts = (await params).slug const fullPath = '/' + slugParts.join('/') // Get entity configs from registry const registry = getEntityConfigs() // Match path to builder entity const match = matchPathToEntity(fullPath, registry) if (match) { const { entity, slug, isArchive } = match // Archive page metadata if (isArchive) { return { title: `${entity.names.plural} | Boilerplate`, description: `Browse all ${entity.names.plural.toLowerCase()}`, } } // Metadata from database const item = await fetchPublishedItem(entity, slug) if (item) { return { title: item.seoTitle || `${item.title} | Boilerplate`, description: item.seoDescription || item.excerpt || undefined, openGraph: { title: item.seoTitle || item.title, description: item.seoDescription || item.excerpt || undefined, images: item.ogImage ? [item.ogImage] : item.featuredImage ? [item.featuredImage] : [], type: 'article', }, } } } return { title: 'Not Found', } } /** * Main catch-all page content (async, wrapped in Suspense by parent) */ async function DynamicPublicPageContent({ params, searchParams, }: PageProps) { const slugParts = (await params).slug const resolvedSearchParams = await searchParams const fullPath = '/' + slugParts.join('/') // Get entity configs from registry const registry = getEntityConfigs() // Match path to builder entity using longest-match strategy const match = matchPathToEntity(fullPath, registry) if (match) { const { entity, slug, isArchive } = match // === ARCHIVE PAGE === // Handle archive pages (e.g., /blog without slug) if (isArchive) { // Check if entity has archive page configured if (!entity.ui?.public?.hasArchivePage) { notFound() } // Check for custom archive template (future enhancement) // For now, use PublicEntityGrid return (

{entity.names.plural}

Browse all {entity.names.plural.toLowerCase()}

) } // === SINGLE ITEM PAGE === // Check for theme template override first const templatePath = buildTemplatePath(entity) if (TemplateService.hasOverride(templatePath)) { const Template = TemplateService.getComponent(templatePath) if (Template) { // Template handles its own data fetching and rendering // Pass params in the format expected by the template return